package it.niedermann.android.markdown

import android.content.Context
import android.graphics.Paint
import android.os.Build
import android.text.Editable
import android.text.Spannable
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.TextUtils
import android.text.style.QuoteSpan
import android.util.Log
import android.util.Pair
import android.widget.TextView
import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat
import androidx.core.text.HtmlCompat
import io.noties.markwon.base.R
import it.niedermann.android.markdown.ListTagHandler.Companion.prepareTagHandling
import it.niedermann.android.markdown.model.EListType
import it.niedermann.android.markdown.model.SearchSpan
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
import java.util.Collections
import java.util.LinkedList
import java.util.Locale
import java.util.Optional
import java.util.function.Function
import java.util.regex.Pattern

object MarkdownUtil {
    private val TAG = MarkdownUtil::class.java.simpleName
    private val PARSER = Parser.builder().build()
    private val RENDERER = HtmlRenderer.builder().softbreak("<br>").build()
    private val PATTERN_CODE_FENCE = Pattern.compile("^(`{3,})")
    private val PATTERN_ORDERED_LIST_ITEM = Pattern.compile("^(\\d+)\\.\\s.+$")
    private val PATTERN_ORDERED_LIST_ITEM_EMPTY = Pattern.compile("^(\\d+)\\.\\s$")
    private val PATTERN_MARKDOWN_LINK = Pattern.compile("\\[(.+)?]\\(([^ ]+?)?( \"(.+)\")?\\)")
    private val PATTERN_QUOTE_BOLD_PUNCTUATION = Pattern.quote("**")
    private val CHECKBOX_CHECKED_EMOJI = getCheckboxEmoji(true)
    private val CHECKBOX_UNCHECKED_EMOJI = getCheckboxEmoji(false)

    /**
     * [RemoteView]s have a limited subset of supported classes to maintain compatibility with many different launchers.
     *
     *
     * Since MarkwonExt makes heavy use of custom spans, this won't look nice e. g. at app widgets, because they simply won't be rendered.
     * Therefore we currently use [HtmlCompat] to filter supported spans from the output of [HtmlRenderer] as an intermediate step.
     */
    fun renderForRemoteView(context: Context, content: String): CharSequence {
        // Create HTML string from Markup
        val html = RENDERER.render(PARSER.parse(replaceCheckboxesWithEmojis(content)))

        // Create Spanned from HTML, with special handling for ordered list items
        val spanned = HtmlCompat.fromHtml(prepareTagHandling(html), 0, null, ListTagHandler())

        // Enhance colors and margins of the Spanned
        return customizeQuoteSpanAppearance(context, spanned, 5, 30)
    }

    private fun customizeQuoteSpanAppearance(
        context: Context,
        input: Spanned,
        stripeWidth: Int,
        gapWidth: Int
    ): Spanned {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
            return input
        }
        val ssb = SpannableStringBuilder(input)
        val originalQuoteSpans = ssb.getSpans(0, ssb.length, QuoteSpan::class.java)
        @ColorInt val colorBlockQuote = ContextCompat.getColor(context, R.color.block_quote)
        for (originalQuoteSpan in originalQuoteSpans) {
            val start = ssb.getSpanStart(originalQuoteSpan)
            val end = ssb.getSpanEnd(originalQuoteSpan)
            ssb.removeSpan(originalQuoteSpan)
            ssb.setSpan(
                QuoteSpan(colorBlockQuote, stripeWidth, gapWidth),
                start,
                end,
                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
            )
        }
        return ssb
    }

    fun replaceCheckboxesWithEmojis(content: String): String {
        return runForEachCheckbox(content) { content: String ->
            var line = content
            for (listType in EListType.values()) {
                if (CHECKBOX_CHECKED_EMOJI.isPresent) {
                    line = line.replace(listType.checkboxChecked, CHECKBOX_CHECKED_EMOJI.get())
                    line = line.replace(
                        listType.checkboxCheckedUpperCase,
                        CHECKBOX_CHECKED_EMOJI.get()
                    )
                }
                if (CHECKBOX_UNCHECKED_EMOJI.isPresent) {
                    line = line.replace(listType.checkboxUnchecked, CHECKBOX_UNCHECKED_EMOJI.get())
                }
            }
            line
        }
    }

    private fun getCheckboxEmoji(checked: Boolean): Optional<String> {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            // Seriously what the fuck, Samsung?
            // https://emojipedia.org/ballot-box-with-x/
            val emojis: Array<String> = if (Build.MANUFACTURER != null && Build.MANUFACTURER.lowercase(Locale.getDefault())
                        .contains("samsung")
                ) {
                    if (checked) arrayOf("✅", "☑️", "✔️") else arrayOf("❌", "\uD83D\uDD32️", "☐️")
                } else {
                    if (checked) arrayOf("☒", "✅", "☑️", "✔️") else arrayOf(
                        "☐",
                        "❌",
                        "\uD83D\uDD32️",
                        "☐️"
                    )
                }
            val paint = Paint()
            for (emoji in emojis) {
                if (paint.hasGlyph(emoji)) {
                    return Optional.of(emoji)
                }
            }
        }
        return Optional.empty()
    }

    /**
     * Performs the given {@param map} function for each line which contains a checkbox
     */
    private fun runForEachCheckbox(markdownString: String, map: Function<String, String>): String {
        val lines =
            markdownString.split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
        var isInFencedCodeBlock = false
        var fencedCodeBlockSigns = 0
        for (i in lines.indices) {
            val matcher = PATTERN_CODE_FENCE.matcher(lines[i])
            if (matcher.find()) {
                val fence = matcher.group(1)
                if (fence != null) {
                    val currentFencedCodeBlockSigns = fence.length
                    if (isInFencedCodeBlock) {
                        if (currentFencedCodeBlockSigns == fencedCodeBlockSigns) {
                            isInFencedCodeBlock = false
                            fencedCodeBlockSigns = 0
                        }
                    } else {
                        isInFencedCodeBlock = true
                        fencedCodeBlockSigns = currentFencedCodeBlockSigns
                    }
                }
            }
            if (!isInFencedCodeBlock) {
                if (lineStartsWithCheckbox(lines[i]) && lines[i].trim { it <= ' ' }.length > EListType.DASH.checkboxChecked.length) {
                    lines[i] = map.apply(lines[i])
                }
            }
        }
        return TextUtils.join("\n", lines)
    }

    fun getStartOfLine(s: CharSequence, cursorPosition: Int): Int {
        var startOfLine = cursorPosition
        while (startOfLine > 0 && s[startOfLine - 1] != '\n') {
            startOfLine--
        }
        return startOfLine
    }

    fun getEndOfLine(s: CharSequence, cursorPosition: Int): Int {
        val nextLinebreak = s.toString().indexOf('\n', cursorPosition)
        return if (nextLinebreak > -1) {
            nextLinebreak
        } else s.length
    }

    fun getListItemIfIsEmpty(line: String): Optional<String> {
        val trimmedLine = line.trim { it <= ' ' }
        // TODO use Java 11 String::repeat
        val builder = StringBuilder()
        val indention = line.indexOf(trimmedLine)
        for (i in 0 until indention) {
            builder.append(" ")
        }
        for (listType in EListType.values()) {
            if (trimmedLine == listType.checkboxUnchecked) {
                return Optional.of(
                    builder.append(listType.checkboxUncheckedWithTrailingSpace).toString()
                )
            } else if (trimmedLine == listType.listSymbol) {
                return Optional.of(builder.append(listType.listSymbolWithTrailingSpace).toString())
            }
        }
        val matcher = PATTERN_ORDERED_LIST_ITEM_EMPTY.matcher(line.substring(indention))
        return if (matcher.find()) {
            Optional.of(builder.append(matcher.group()).toString())
        } else Optional.empty()
    }

    fun setCheckboxStatus(
        markdownString: String,
        targetCheckboxIndex: Int,
        newCheckedState: Boolean
    ): CharSequence {
        val lines =
            markdownString.split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
        var checkboxIndex = 0
        var isInFencedCodeBlock = false
        var fencedCodeBlockSigns = 0
        for (i in lines.indices) {
            val matcher = PATTERN_CODE_FENCE.matcher(lines[i])
            if (matcher.find()) {
                val fence = matcher.group(1)
                if (fence != null) {
                    val currentFencedCodeBlockSigns = fence.length
                    if (isInFencedCodeBlock) {
                        if (currentFencedCodeBlockSigns == fencedCodeBlockSigns) {
                            isInFencedCodeBlock = false
                            fencedCodeBlockSigns = 0
                        }
                    } else {
                        isInFencedCodeBlock = true
                        fencedCodeBlockSigns = currentFencedCodeBlockSigns
                    }
                }
            }
            if (!isInFencedCodeBlock) {
                if (lineStartsWithCheckbox(lines[i]) && lines[i].trim { it <= ' ' }.length > EListType.DASH.checkboxChecked.length) {
                    if (checkboxIndex == targetCheckboxIndex) {
                        val indexOfStartingBracket = lines[i].indexOf("[")
                        val toggledLine = lines[i].substring(0, indexOfStartingBracket + 1) +
                                (if (newCheckedState) 'x' else ' ') +
                                lines[i].substring(indexOfStartingBracket + 2)
                        lines[i] = toggledLine
                        break
                    }
                    checkboxIndex++
                }
            }
        }
        return TextUtils.join("\n", lines)
    }

    fun lineStartsWithCheckbox(line: String): Boolean {
        for (listType in EListType.values()) {
            if (lineStartsWithCheckbox(line, listType)) {
                return true
            }
        }
        return false
    }

    fun lineStartsWithCheckbox(line: String, listType: EListType): Boolean {
        val trimmedLine = line.trim { it <= ' ' }
        return trimmedLine.startsWith(listType.checkboxUnchecked) || trimmedLine.startsWith(listType.checkboxChecked) || trimmedLine.startsWith(
            listType.checkboxCheckedUpperCase
        )
    }

    /**
     * @return the number of the ordered list item if the line is an ordered list, otherwise -1.
     */
    fun getOrderedListNumber(line: String): Optional<Int> {
        val matcher = PATTERN_ORDERED_LIST_ITEM.matcher(line)
        if (matcher.find()) {
            val groupNumber = matcher.group(1)
            if (groupNumber != null) {
                return try {
                    Optional.of(groupNumber.toInt())
                } catch (e: NumberFormatException) {
                    Optional.empty()
                }
            }
        }
        return Optional.empty()
    }

    /**
     * Modifies the {@param editable} and adds the given {@param punctuation} from
     * {@param selectionStart} to {@param selectionEnd} or removes the {@param punctuation} in case
     * it already is around the selected part.
     *
     * @return the new cursor position
     */
    fun togglePunctuation(
        editable: Editable,
        selectionStart: Int,
        selectionEnd: Int,
        punctuation: String
    ): Int {
        val initialString = editable.toString()
        if (selectionStart < 0 || selectionStart > initialString.length || selectionEnd < 0 || selectionEnd > initialString.length) {
            return 0
        }

        // handle special case: italic (that damn thing will match like ANYTHING (regarding bold / bold+italic)....)
        val isItalic = punctuation.length == 1 && punctuation[0] == '*'
        if (isItalic) {
            val result = handleItalicEdgeCase(editable, initialString, selectionStart, selectionEnd)
            // The result is only present if this actually was an edge case
            if (result.isPresent) {
                return result.get()
            }
        }

        // handle the simple cases
        val wildcardRex = "([^" + punctuation[0] + "])+"
        val punctuationRex = Pattern.quote(punctuation)
        val pattern =
            if (isItalic // in this case let's make optional asterisks around it, so it wont match anything between two (bold+italic)s
            ) "\\*?\\*?$punctuationRex$wildcardRex$punctuationRex\\*?\\*?" else punctuationRex + wildcardRex + punctuationRex
        val searchPattern = Pattern.compile(pattern)
        var relevantStart = selectionStart - 2
        relevantStart = Math.max(relevantStart, 0)
        var relevantEnd = selectionEnd + 2
        relevantEnd = Math.min(relevantEnd, initialString.length)
        val matcher = searchPattern.matcher(initialString).region(relevantStart, relevantEnd)

        // if the matcher matches, it's a remove
        if (matcher.find()) {
            // this resets the matcher, while keeping the required region
            matcher.region(relevantStart, relevantEnd)
            val punctuationLength = punctuation.length
            val startEnd = LinkedList<Pair<Int, Int>?>()
            var removedCount = 0
            while (matcher.find()) {
                startEnd.add(Pair(matcher.start(), matcher.end()))
                removedCount += punctuationLength
            }
            // start from the end
            Collections.reverse(startEnd)
            for (item in startEnd) {
                deletePunctuation(editable, punctuationLength, item!!.first, item.second)
            }
            var offsetAtEnd = 0
            // depending on if the user has selected the markdown chars, we might need to add an offset to the resulting cursor position
            if (initialString.substring(
                    Math.max(selectionEnd - punctuationLength + 1, 0),
                    Math.min(selectionEnd + 1, initialString.length)
                ) == punctuation || initialString.substring(
                    selectionEnd,
                    Math.min(selectionEnd + punctuationLength, initialString.length)
                ) == punctuation
            ) {
                offsetAtEnd = punctuationLength
            }
            return selectionEnd - removedCount * 2 + offsetAtEnd
            //                                 ^
            //         start+end, need to double
        }

        // do nothing when punctuation is contained only once
        return if (Pattern.compile(punctuationRex).matcher(initialString)
                .region(selectionStart, selectionEnd).find()
        ) {
            selectionEnd
        } else insertPunctuation(editable, selectionStart, selectionEnd, punctuation)

        // nothing returned so far, so it has to be an insertion
    }

    private fun deletePunctuation(
        editable: Editable,
        punctuationLength: Int,
        start: Int,
        end: Int
    ) {
        editable.delete(end - punctuationLength, end)
        editable.delete(start, start + punctuationLength)
    }

    /**
     * @return an [<] of the new cursor position.
     * The return value is only [Optional.isPresent], if this is an italic edge case.
     */
    private fun handleItalicEdgeCase(
        editable: Editable,
        editableAsString: String,
        selectionStart: Int,
        selectionEnd: Int
    ): Optional<Int> {
        // look if selection is bold, this is the only edge case afaik
        val searchPattern =
            Pattern.compile("(^|[^*])$PATTERN_QUOTE_BOLD_PUNCTUATION([^*])*$PATTERN_QUOTE_BOLD_PUNCTUATION([^*]|$)")
        // look the selection expansion by 1 is intended, so the NOT '*' has a chance to match. we don't want to match ***blah***
        val matcher = searchPattern.matcher(editableAsString)
            .region(
                Math.max(selectionStart - 1, 0),
                Math.min(selectionEnd + 1, editableAsString.length)
            )
        if (matcher.find()) {
            return Optional.of(insertPunctuation(editable, selectionStart, selectionEnd, "*"))
        }
        // look around (3 chars) (NOT '*' + "**"). User might have selected the text only
        return if (matcher.region(
                Math.max(selectionStart - 3, 0),
                Math.min(selectionEnd + 3, editableAsString.length)
            ).find()
        ) {
            Optional.of(
                insertPunctuation(
                    editable,
                    selectionStart,
                    selectionEnd,
                    "*"
                )
            )
        } else Optional.empty()
    }

    private fun insertPunctuation(
        editable: Editable,
        firstPosition: Int,
        secondPosition: Int,
        punctuation: String
    ): Int {
        editable.insert(secondPosition, punctuation)
        editable.insert(firstPosition, punctuation)
        return secondPosition + punctuation.length
    }

    /**
     * Inserts a link into the given {@param editable} from {@param selectionStart} to {@param selectionEnd} and uses the {@param clipboardUrl} if available.
     *
     * @return the new cursor position
     */
    fun insertLink(
        editable: Editable,
        selectionStart: Int,
        selectionEnd: Int,
        clipboardUrl: String?
    ): Int {
        var selectionStart = selectionStart
        var selectionEnd = selectionEnd
        return if (selectionStart == selectionEnd) {
            if (selectionStart > 0 && selectionEnd < editable.length) {
                var start = editable[selectionStart - 1]
                var end = editable[selectionEnd]
                if (start == ' ' || end == ' ') {
                    if (start != ' ') {
                        editable.insert(selectionStart, " ")
                        selectionStart += 1
                    }
                    if (end != ' ') {
                        editable.insert(selectionEnd, " ")
                    }
                    editable.insert(selectionStart, "[](" + (clipboardUrl ?: "") + ")")
                    selectionStart + 1
                } else {
                    while (start != ' ') {
                        selectionStart--
                        start = editable[selectionStart]
                    }
                    selectionStart++
                    while (end != ' ') {
                        selectionEnd++
                        end = editable[selectionEnd]
                    }
                    selectionEnd++
                    editable.insert(selectionStart, "[")
                    editable.insert(selectionEnd, "](" + (clipboardUrl ?: "") + ")")
                    if (clipboardUrl != null) {
                        selectionEnd += clipboardUrl.length
                    }
                    selectionEnd + 2
                }
            } else {
                editable.insert(selectionStart, "[](" + (clipboardUrl ?: "") + ")")
                selectionStart + 1
            }
        } else {
            val textToFormatIsLink = TextUtils.indexOf(
                editable.subSequence(selectionStart, selectionEnd),
                "http"
            ) == 0
            if (textToFormatIsLink) {
                if (clipboardUrl == null) {
                    editable.insert(selectionEnd, ")")
                    editable.insert(selectionStart, "[](")
                } else {
                    editable.insert(selectionEnd, "]($clipboardUrl)")
                    editable.insert(selectionStart, "[")
                    selectionEnd += clipboardUrl.length
                }
            } else {
                if (clipboardUrl == null) {
                    editable.insert(selectionEnd, "]()")
                } else {
                    editable.insert(selectionEnd, "]($clipboardUrl)")
                    selectionEnd += clipboardUrl.length
                }
                editable.insert(selectionStart, "[")
            }
            if (textToFormatIsLink && clipboardUrl == null) selectionStart + 1 else selectionEnd + 3
        }
    }

    fun selectionIsInLink(text: CharSequence, start: Int, end: Int): Boolean {
        val matcher = PATTERN_MARKDOWN_LINK.matcher(text)
        while (matcher.find()) {
            if (start >= matcher.start() && start < matcher.end() || end > matcher.start() && end <= matcher.end()) {
                return true
            }
        }
        return false
    }

    fun searchAndColor(
        editable: Spannable,
        searchText: CharSequence?,
        current: Int?,
        @ColorInt mainColor: Int,
        @ColorInt highlightColor: Int,
        darkTheme: Boolean
    ) {
        if (searchText != null) {
            val m = Pattern
                .compile(searchText.toString(), Pattern.CASE_INSENSITIVE or Pattern.LITERAL)
                .matcher(editable)
            var i = 1
            while (m.find()) {
                val start = m.start()
                val end = m.end()
                editable.setSpan(
                    SearchSpan(
                        mainColor,
                        highlightColor,
                        current != null && i == current,
                        darkTheme
                    ), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
                )
                i++
            }
        }
    }

    /**
     * Removes all spans of {@param spanType} from {@param spannable}.
     */
    fun <T> removeSpans(spannable: Spannable, spanType: Class<T>) {
        for (span in spannable.getSpans(0, spannable.length, spanType)) {
            spannable.removeSpan(span)
        }
    }

    /**
     * @return When the content of the {@param textView} is already of type [Spannable], it will cast and return it directly.
     * Otherwise it will create a new [SpannableString] from the content, set this as new content of the {@param textView} and return it.
     */
    fun getContentAsSpannable(textView: TextView): Spannable {
        val content = textView.text
        return if (content.javaClass == SpannableString::class.java || content is Spannable) {
            content as Spannable
        } else {
            Log.w(
                TAG,
                "Expected " + TextView::class.java.simpleName + " content to be of type " + Spannable::class.java.simpleName + ", but was of type " + content.javaClass + ". Search highlighting will be not performant."
            )
            val spannableContent = SpannableString(content)
            textView.setText(spannableContent, TextView.BufferType.SPANNABLE)
            spannableContent
        }
    }

    fun getMarkdownLink(text: String, url: String): String {
        return "[$text]($url)"
    }

    /**
     * Strips all Markdown from {@param s}
     *
     * @param s Markdown string
     * @return Plain text string
     */
    fun removeMarkdown(s: String?): String {
        if (TextUtils.isEmpty(s)) {
            return ""
        }
        assert(s != null)
        val html = RENDERER.render(
            PARSER.parse(
                replaceCheckboxesWithEmojis(
                    s!!
                )
            )
        )
        val spanned = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT)
        return if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
            spanned.toString().trim { it <= ' ' }.replace("\n\n".toRegex(), "\n")
        } else spanned.toString().trim { it <= ' ' }
    }
}